WebView와 Native의 책임 경계
WebView와 Native의 책임 경계
WebView와 Native를 나누는 기준은 언어나 화면 모양이 아니라 누가 원본 상태와 최종 부수효과를 소유하는가이다. 웹은 웹 도메인 화면과 라우팅을, Native는 OS 권한·보안 저장소·파일·푸시·앱 수명주기를 소유하게 한다. Bridge는 양쪽 상태를 동기화하는 만능 통로가 아니라, Web이 허용된 Native capability를 요청하고 명시적인 결과를 받는 좁은 프로토콜이어야 한다.
목차
- #WebView를 넣은 순간 생기는 두 개의 애플리케이션
- #책임을 코드 위치가 아니라 소유권으로 나누기
- #기능별 단일 소유자 정하기
- #Web은 화면과 웹 도메인 흐름을 소유한다
- #Native는 OS capability와 보안 경계를 소유한다
- #Bridge는 공유 서비스가 아니라 제한된 포트다
- #같은 상태를 양쪽에서 수정하지 않기
- #네트워크 요청의 소유자를 일관되게 정하기
- #Navigation에는 두 개의 stack이 있다
- #파일과 카메라는 handle 중심으로 전달하기
- #수명주기가 끊겨도 복구할 수 있게 만들기
- #웹과 앱의 배포 시차를 견디기
- #신뢰하지 않는 페이지에서 Bridge를 제거하기
- #테스트와 관측 경계도 함께 나누기
- #책임 경계 체크리스트
- #마무리
- #관련 노트
- #참고 자료
WebView를 넣은 순간 생기는 두 개의 애플리케이션
WebView 기반 앱은 겉으로는 화면 하나지만 내부에는 서로 다른 실행 환경이 있다.
- Dart·Swift·Kotlin이 관리하는 Native shell
- HTML·CSS·JavaScript가 관리하는 Web application
두 환경은 배포 주기, navigation, memory, storage, error model이 다르다. Flutter 상태가 바뀌었다고 Web의 React state가 자동으로 바뀌지 않고, SPA route가 이동했다고 Native Navigator stack이 줄어들지 않는다. 앱이 background로 가면 Web timer가 지연될 수 있고, page reload가 발생하면 JavaScript memory는 사라진다.
flowchart TB
subgraph Native["Native shell"]
N1[App lifecycle]
N2[OS permissions]
N3[Secure storage]
N4[Native navigation]
end
subgraph Bridge["Versioned bridge"]
B1[Request validation]
B2[Capability dispatch]
B3[Result and error mapping]
end
subgraph Web["Web application"]
W1[Web domain state]
W2[SPA routing]
W3[Forms and presentation]
W4[Web release]
end
Web --> Bridge --> Native
Native --> Bridge --> Web문제는 WebView를 썼다는 사실보다 두 애플리케이션 사이의 책임이 문서화되지 않았을 때 생긴다.
예를 들어 로그인 상태를 Native도 갖고 Web의 localStorage도 갖게 하면 어느 쪽이 원본인지 불분명해진다. 카메라 권한을 Web JavaScript가 판단하면서 Native도 별도의 permission state를 cache하면 설정 앱에서 권한이 바뀐 뒤 두 값이 달라질 수 있다.
“이 코드를 어느 폴더에 둘까?”가 아니라 “이 기능의 최종 결정을 내리고 부수효과를 실행하며 복구를 책임지는 쪽은 어디인가?”를 묻는다.
예제는 특정 프로젝트의 실제 코드를 가져오지 않고, 주문 내역 Web UI를 Flutter shell 안에서 실행한다는 가상의 구조로 재구성했다.
책임을 코드 위치가 아니라 소유권으로 나누기
Native와 Web을 나누는 기준을 “Native 기능이면 Native, 화면이면 Web”으로만 두면 애매한 사례가 많다. 파일 업로드 화면은 Web UI지만 picker는 OS 기능이고, 로그인 화면은 Web이어도 refresh token은 Native Keychain이 소유할 수 있다.
기능마다 다음 세 가지를 정하면 경계가 선명해진다.
- 결정권: 실행 여부와 정책을 누가 판단하는가
- 원본 상태: 충돌할 때 어느 값을 신뢰하는가
- 부수효과: 권한 요청, 파일 생성, token 저장을 누가 확정하는가
기능: 사진 첨부
결정권: Web은 사용자의 첨부 의도를 결정
원본 상태: 선택 완료 전에는 Native picker, 완료 후에는 서버 attachment ID
부수효과: Native가 photo picker 실행, Web이 업로드 요청과 form 연결
기능: 로그아웃
결정권: 인증 도메인 정책
원본 상태: Native session repository
부수효과: Native가 credential 삭제, Web은 memory state와 화면 초기화
양쪽이 같은 책임을 “도와준다”는 표현은 피한다. 실패했을 때 최종 복구를 누가 하는지까지 정해야 실제 소유자가 드러난다.
기능별 단일 소유자 정하기
기본 책임표를 먼저 만들고 예외가 있을 때 근거를 기록한다.
| 기능 | 기본 소유자 | 상대 영역의 역할 |
|---|---|---|
| 웹 도메인 화면·form validation | Web | Native는 container 제공 |
| SPA route와 page state | Web | Native에 route summary 통지 |
| OS 권한 요청 | Native | Web은 capability 요청과 결과 표시 |
| camera·photo picker | Native | Web은 선택 의도와 결과 handle 소비 |
| push token·notification tap | Native | Web에 정제된 navigation command 전달 |
| refresh token·Keychain | Native | Web은 짧은 session capability 사용 |
| Web API 호출 | Web 또는 Native 중 하나 | 중복 client 금지 |
| 앱 deep link | Native 진입, Web route 해석 | 명시적 route mapping |
| Web bundle version | Native manifest와 Web | handshake로 호환성 확인 |
| analytics | event 소유자가 생성 | 공통 correlation ID 사용 |
이 표는 절대 규칙이 아니다. offline bundle을 Native가 관리하고 Web의 API를 Native proxy가 호출하는 앱도 있다. 중요한 것은 한 기능에 두 명의 최종 소유자를 만들지 않는 것이다.
같은 boolean을 양쪽에서 저장하거나, Web 오류를 고치기 위해 앱 배포가 필요하고 Native 오류를 고치기 위해 웹 배포가 필요한 상황이 반복되면 책임이 순환하고 있을 가능성이 크다.
Web은 화면과 웹 도메인 흐름을 소유한다
Web으로 선택한 업무 흐름은 가능한 한 Web 안에서 완결한다.
- form 상태와 validation
- API 응답을 화면 model로 변환
- loading, empty, error UI
- SPA 내부 route
- 웹 접근성
- 브라우저에서도 재사용할 도메인 규칙
예를 들어 주문 취소 확인 modal을 Native에 만들고 실제 취소 요청은 Web이 보내면 Web state와 Native modal state를 함께 맞춰야 한다. OS UI가 필요한 것이 아니라면 Web이 modal부터 요청 결과까지 소유하는 편이 단순하다.
type CancelOrderState =
| { type: "idle" }
| { type: "confirming"; orderId: string }
| { type: "submitting"; orderId: string }
| { type: "failed"; orderId: string; message: string };
async function confirmCancellation(orderId: string) {
setState({ type: "submitting", orderId });
try {
await orderApi.cancel(orderId);
router.replace("/orders");
} catch {
setState({
type: "failed",
orderId,
message: "주문을 취소하지 못했습니다.",
});
}
}
Web이 Native에 “취소 modal을 띄워 달라”고 요청할 이유가 없다. 반대로 system share sheet, biometric prompt, photo picker처럼 OS가 제공하는 UX는 Native에 요청한다.
Native는 OS capability와 보안 경계를 소유한다
Native가 소유해야 자연스러운 기능에는 공통점이 있다.
- entitlement나 OS permission이 필요하다.
- 앱 lifecycle과 연결된다.
- secure storage나 sandbox file에 접근한다.
- push, deep link, background task처럼 앱 진입점을 만든다.
- WebView가 교체돼도 앱 수준에서 유지돼야 한다.
Web은 capability를 요청하지만 권한의 현재 상태를 임의로 확정하지 않는다.
const result = await nativeBridge.request({
version: 1,
type: "PHOTO_PICKER_OPEN",
requestId: crypto.randomUUID(),
payload: {
selectionLimit: 3,
mediaTypes: ["image"],
},
});
if (result.status === "cancelled") {
return;
}
if (result.status === "denied") {
showPermissionGuide(result.canOpenSettings);
return;
}
attachFiles(result.files);
Web은 permissionGranted = true를 오래 cache하지 않는다. 사용자는 앱 밖의 Settings에서 권한을 바꿀 수 있다. Native가 실행 시점에 OS의 현재 상태를 확인하고 granted, denied, limited, cancelled, unavailable처럼 의미 있는 결과를 반환한다.
Bridge는 공유 서비스가 아니라 제한된 포트다
나쁜 bridge는 Native 객체를 JavaScript에 넓게 노출한다.
// 피해야 할 형태
window.Native.execute("FileManager", "read", arbitraryPath);
window.Native.setValue(arbitraryKey, arbitraryValue);
window.Native.open(arbitraryUrl);
이 API는 호출자가 파일 경로, 저장 key, URL scheme을 자유롭게 선택한다. XSS나 신뢰하지 않는 iframe이 bridge에 도달했을 때 Native 앱 권한으로 기능을 실행할 가능성이 커진다.
좋은 bridge는 허용한 업무 capability만 노출한다.
type NativeRequest =
| {
version: 1;
type: "PHOTO_PICKER_OPEN";
requestId: string;
payload: {
selectionLimit: number;
mediaTypes: Array<"image">;
};
}
| {
version: 1;
type: "SHARE_ORDER_LINK";
requestId: string;
payload: {
orderPublicId: string;
};
}
| {
version: 1;
type: "APP_SETTINGS_OPEN";
requestId: string;
payload: {};
};
Web은 임의 URL을 share sheet로 넘기지 않고 공개 주문 ID만 보낸다. Native가 허용된 base URL로 최종 링크를 만든다.
Future<ShareResult> shareOrderLink(
ShareOrderLink request,
) async {
if (!orderIdPattern.hasMatch(request.orderPublicId)) {
return const ShareResult.invalidArgument();
}
final uri = Uri.https(
'app.example.invalid',
'/orders/${request.orderPublicId}',
);
return shareSheet.present(uri);
}
bridge에 새 capability를 추가하는 것은 public API를 추가하는 것처럼 review한다.
- 어느 page와 frame이 호출할 수 있는가
- 입력 범위는 무엇인가
- Native 부수효과는 무엇인가
- 사용자의 명시적 gesture가 필요한가
- 중복 요청은 안전한가
- timeout과 취소 결과는 무엇인가
- 로그에 남길 수 있는 필드는 무엇인가
envelope과 version 호환성은 WebView 브릿지를 버전 있는 프로토콜로 만들기에서 상세히 다룬다.
같은 상태를 양쪽에서 수정하지 않기
Hybrid 앱에서 가장 어려운 버그는 값 자체보다 소유권 충돌에서 나온다.
sequenceDiagram
participant Web
participant Native
participant Server
Web->>Web: localStorage session 갱신
Native->>Server: refresh token으로 갱신
Server-->>Native: 새 access token
Web->>Server: 이전 token으로 요청
Server-->>Web: 401
Web->>Web: 로그아웃 처리
Native->>Web: 새 session 전달
Note over Web,Native: 로그인/로그아웃 상태가 진동인증 credential의 원본을 Native로 정했다면 Web은 memory 안의 짧은 access session만 사용하고 refresh와 영속화를 Native에 맡긴다. Web이 401을 받았을 때 자체 refresh를 시작하지 않고 bridge에 session 재확인을 요청한다.
상태를 다음 세 종류로 구분하면 도움이 된다.
| 상태 종류 | 예 | 처리 |
|---|---|---|
| source of truth | Keychain refresh credential | 소유자만 수정 |
| projection | Web에 전달한 access session | 만료 가능, 재요청 |
| notification | SESSION_CHANGED event |
값이 아니라 다시 읽으라는 신호 |
notification에 전체 비밀값을 계속 broadcast하지 않는다. “상태가 변했다”는 event를 받고 Web이 필요한 projection을 명시적으로 요청하게 할 수 있다.
네트워크 요청의 소유자를 일관되게 정하기
Web fetch와 Native HTTP client가 같은 API를 나눠 호출하면 cookie, cache, retry, 인증 refresh, tracing이 두 벌이 된다. endpoint 단위로 임의 분배하지 말고 경계 원칙을 정한다.
Web이 API를 직접 호출하는 방식
웹 브라우저와 WebView에서 같은 앱을 재사용하기 쉽다. Web 도메인 요청은 Web이 소유하고, Native는 session bootstrap과 device capability만 제공한다.
Web UI → Web API client → Server
↑
Native session capability
Native가 API proxy가 되는 방식
certificate pinning, 통합 offline queue, Native SDK가 필수라면 Native가 호출하고 Web은 typed bridge request를 보낼 수 있다.
Web UI → Bridge → Native API client → Server
이 방식에서는 bridge가 사실상 API gateway가 되므로 pagination, cancellation, large response, error schema 비용이 커진다. 모든 서버 DTO를 bridge로 복제할 정도라면 WebView 구조가 여전히 이득인지 다시 평가한다.
일반 업무 API는 Web, push token 등록이나 background upload는 Native처럼 capability 경계로 나눌 수 있다. 다만 같은 resource를 양쪽 cache가 독립적으로 수정하지 않게 invalidation 규칙을 둔다.
Navigation에는 두 개의 stack이 있다
Native Navigator와 Web history는 서로 다른 stack이다.
Native stack
Home → HybridScreen → NativePhotoPreview
Web history inside HybridScreen
/orders → /orders/42 → /orders/42/edit
뒤로가기의 우선순위와 deep link mapping을 책임표에 포함한다.
- Web 내부 route라면 Web router가 처리
- Web root라면 Native stack에 위임
- Native modal이 열려 있다면 modal을 먼저 닫음
- 외부 origin navigation은 system browser로 보냄
Web이 Native 화면을 열 때 “화면 class 이름”을 요청하지 않는다.
{
"version": 1,
"type": "ORDER_ATTACHMENT_PREVIEW",
"requestId": "req-demo-42",
"payload": {
"attachmentHandle": "attachment-demo-7"
}
}
Native는 현재 app state와 handle 유효성을 확인한 뒤 적절한 화면을 선택한다. 뒤로가기 state machine은 WebView 뒤로가기와 Native 내비게이션 연결에서 이어서 정리한다.
파일과 카메라는 handle 중심으로 전달하기
Web에 sandbox 절대 경로를 넘기면 경로 수명과 권한이 Web 계약에 새어 나온다.
{
"path": "/private/var/mobile/Containers/Data/temporary/photo.jpg"
}
대신 제한된 수명의 opaque handle과 필요한 metadata를 전달한다.
{
"handle": "upload-demo-8f1",
"mediaType": "image/jpeg",
"size": 284102,
"expiresAtEpochMs": 1760000000000
}
Web은 이 handle로 허용된 후속 작업만 요청한다.
await nativeBridge.request({
version: 1,
type: "ATTACHMENT_UPLOAD",
requestId: crypto.randomUUID(),
payload: {
handle: selectedFile.handle,
uploadPurpose: "order-evidence",
},
});
Native는 handle을 자기 registry에서 찾고 다음을 검증한다.
- 현재 WebView instance가 발급받은 handle인가
- 만료되지 않았는가
- MIME type과 size가 목적에 맞는가
- 이미 소비된 one-time handle인가
- 실제 URL이 허용된 temporary directory 안에 있는가
작업 완료, 취소, WebView 폐기 때 temporary file을 정리한다. 큰 byte array를 JSON이나 JavaScript string으로 왕복시키는 것도 피한다.
수명주기가 끊겨도 복구할 수 있게 만들기
Web page는 reload될 수 있고 Native 화면은 background로 갈 수 있다. photo picker를 연 사이 WebView가 재생성될 수도 있다.
stateDiagram-v2
[*] --> Requested
Requested --> NativeUI: request accepted
NativeUI --> Completed: user selected
NativeUI --> Cancelled: user cancelled
NativeUI --> Suspended: WebView destroyed
Suspended --> Deliverable: same session restored
Suspended --> Discarded: session expired
Completed --> [*]
Cancelled --> [*]
Deliverable --> [*]
Discarded --> [*]requestId와 WebView sessionId를 구분한다.
requestId: 한 요청과 응답을 연결sessionId: page reload 전후의 bridge instance를 구분
이전 page가 시작한 결과를 새 page에 무조건 전달하면 사용자가 다른 계정이나 다른 form에 있을 수 있다. 복구 가능한 작업만 명시적으로 reattach하고, 나머지는 취소 처리한다.
Native에서 handler를 등록했으면 WebView dispose 시 해제한다. Web에서도 pending Promise를 timeout 또는 BRIDGE_DISCONNECTED로 종료한다. 아무 응답 없이 남겨 두면 loading UI가 영구히 지속된다.
웹과 앱의 배포 시차를 견디기
원격 Web을 로드한다면 Web은 오늘 배포되고 Native 앱은 사용자의 기기에 몇 달 전 버전으로 남을 수 있다. 반대로 offline bundle은 새 앱에 들어 있지만 서버 feature flag는 구버전일 수 있다.
초기 handshake에서 capability를 교환한다.
{
"bridgeProtocol": {
"min": 1,
"max": 2
},
"nativeAppVersion": "5.4.0",
"capabilities": [
"photo-picker.v2",
"share-order-link.v1",
"secure-session.v1"
]
}
Web은 앱 버전 문자열을 비교해 기능을 추측하기보다 capability 존재를 확인한다.
if (bridgeCapabilities.has("photo-picker.v2")) {
showNativePhotoPickerButton();
} else {
showWebFileInputFallback();
}
필수 protocol 범위가 겹치지 않으면 어설프게 실행하지 않고 업데이트 안내나 호환 가능한 fallback 화면을 보여 준다. 알 수 없는 optional field는 무시하고, 필수 의미가 바뀌는 변경은 새 capability version으로 분리한다.
신뢰하지 않는 페이지에서 Bridge를 제거하기
WebView가 bridge를 가진 채 외부 링크로 이동하면 외부 페이지도 Native capability에 닿을 수 있다. Android 공식 보안 안내는 JavaScript interface가 모든 frame에 노출될 수 있고 호출 frame의 origin 검증이 어려운 위험을 지적한다. 신뢰하는 콘텐츠만 로드하고 navigation을 제한하는 것이 기본이다.
허용 정책은 정확한 origin 단위로 둔다.
final allowedOrigins = <String>{
'https://app.example.invalid',
};
NavigationDecision decideNavigation(Uri uri) {
final origin = '${uri.scheme}://${uri.host}'
'${uri.hasPort ? ':${uri.port}' : ''}';
if (allowedOrigins.contains(origin)) {
return NavigationDecision.navigate;
}
externalBrowser.open(uri);
return NavigationDecision.prevent;
}
실제 비교에서는 URL parser를 사용하고 scheme, normalized host, port를 모두 포함한다. host.endsWith('example.com') 같은 검사는 evil-example.com을 허용할 수 있다.
iOS의 WKScriptMessage에는 message body뿐 아니라 frameInfo, web view, content world 정보가 있다. top frame과 현재 navigation의 신뢰 상태를 확인한다. WKContentWorld는 app script와 page script의 JavaScript namespace 충돌을 줄여 주지만 DOM은 공유되므로 content world만으로 신뢰 문제가 모두 해결되지는 않는다.
외부 콘텐츠를 반드시 보여 줘야 한다면 다음 중 하나를 택한다.
- bridge가 없는 별도 WebView 사용
- system browser 또는 in-app browser 사용
- navigation 전에 handler 제거
- 허용된 top-level document에서만 capability 활성화
XSS가 발생해도 bridge가 camera, file, token을 넓게 제공하지 않도록 capability 자체도 최소화한다. origin 검사는 입력 검증을 대체하지 않는다.
테스트와 관측 경계도 함께 나누기
책임 경계는 테스트 소유권에도 반영한다.
| 테스트 | 주요 검증 |
|---|---|
| Web unit test | UI state, request 생성, bridge error mapping |
| Bridge contract test | version, type, payload, request-response 상관관계 |
| Native unit test | permission·file·session capability 구현 |
| WebView integration test | 실제 serialization, navigation, lifecycle |
| 보안 test | 외부 origin·iframe·잘못된 payload 거절 |
| 실제 기기 test | OS picker, permission 변경, background 복귀 |
fake bridge는 성공만 반환하지 않고 취소·권한 거절·timeout·연결 끊김을 표현한다.
class FakeNativeBridge implements NativeBridge {
constructor(
private readonly handler: (
request: NativeRequest,
) => Promise<NativeResponse>,
) {}
requests: NativeRequest[] = [];
async request(
request: NativeRequest,
): Promise<NativeResponse> {
this.requests.push(request);
return this.handler(request);
}
}
운영 로그는 양쪽의 event를 requestId로 연결하되 payload 원문은 남기지 않는다.
web_bridge_request type=PHOTO_PICKER_OPEN request_id=req-42
native_capability_result type=PHOTO_PICKER_OPEN result=cancelled duration_ms=820
web_bridge_settled type=PHOTO_PICKER_OPEN result=cancelled
Web error tracker와 Native crash report에 같은 correlation ID를 넣으면 어느 경계에서 사라졌는지 찾기 쉽다. token, file path, 사용자 입력은 로그에서 제외한다.
책임 경계 체크리스트
마무리
WebView와 Native의 책임을 나누는 목적은 코드 폴더를 깔끔하게 만드는 데 있지 않다. 두 실행 환경이 서로 다른 lifecycle과 배포 주기를 가져도 하나의 기능이 예측 가능하게 동작하도록 만드는 데 있다.
Web은 선택한 웹 도메인의 화면, validation, SPA navigation을 소유한다. Native는 OS permission, secure storage, file, push처럼 플랫폼 권한이 필요한 capability를 소유한다. 인증이나 navigation처럼 양쪽에 걸친 기능도 원본 상태와 최종 부수효과의 소유자는 하나만 둔다.
Bridge는 이 경계를 없애는 도구가 아니다. 오히려 경계를 코드로 드러내는 protocol이다. 임의 명령을 실행하는 범용 객체 대신 버전이 있는 작은 capability를 제공하고, 입력·origin·frame·lifecycle을 검증한다. 그러면 Web과 Native는 서로의 내부 구현을 복제하지 않고도 필요한 결과만 교환할 수 있다.
관련 노트
- WebView 브릿지를 버전 있는 프로토콜로 만들기
- WebView 로그인 세션을 안전하게 전달하기
- WebView 뒤로가기와 Native 내비게이션 연결
- React Native WebView 오프라인 런타임 설계
- MethodChannel로 Flutter와 네이티브 코드 연결하기
- Flutter Navigator와 선언형 라우팅 비교
- 구조화 로그에 Request ID 넣기